import os
import threading
import stat
import struct
import time
import atexit
import ctypes
import ctypes.wintypes

# Note that when we open a file for writing and it already existed, Windows
# immediately sends a modification notification.  It doesn't look like this
# notification can be avoided.

##############################################################################

# The Windows constants we'll need:
win_INVALID_HANDLE_VALUE = ctypes.wintypes.HANDLE(-1)
win_OPEN_EXISTING = 3

win_THREAD_TERMINATE = 0x0001

win_ERROR_OPERATION_ABORTED = 0x03E3

win_FILE_NOTIFY_CHANGE_FILE_NAME = 0x01
win_FILE_NOTIFY_CHANGE_DIR_NAME = 0x02
win_FILE_NOTIFY_CHANGE_ATTRIBUTES = 0x04
win_FILE_NOTIFY_CHANGE_SIZE = 0x08
win_FILE_NOTIFY_CHANGE_LAST_WRITE = 0x010
win_FILE_NOTIFY_CHANGE_LAST_ACCESS = 0x020
win_FILE_NOTIFY_CHANGE_CREATION = 0x040
win_FILE_NOTIFY_CHANGE_SECURITY = 0x0100

win_FILE_LIST_DIRECTORY = 0x01
win_FILE_SHARE_READ = 0x01
win_FILE_SHARE_WRITE = 0x02
win_FILE_SHARE_DELETE = 0x04
win_FILE_FLAG_BACKUP_SEMANTICS = 0x02000000

win_FILE_ACTION_ADDED = 1
win_FILE_ACTION_REMOVED = 2
win_FILE_ACTION_MODIFIED = 3
win_FILE_ACTION_RENAMED_OLD_NAME = 4
win_FILE_ACTION_RENAMED_NEW_NAME = 5

_file_action_names = {
    win_FILE_ACTION_ADDED: "added",
    win_FILE_ACTION_REMOVED: "removed",
    win_FILE_ACTION_MODIFIED: "modified",
    win_FILE_ACTION_RENAMED_OLD_NAME: "renamed from",
    win_FILE_ACTION_RENAMED_NEW_NAME: "renamed to",
}

# Look up the Windows C API functions we'll need.

def _error_check_bool(result, func, args):
    if not result:
        raise ctypes.WinError()
    return args

def _error_check_handle(result, func, args):
    if result == win_INVALID_HANDLE_VALUE:
        raise ctypes.WinError()
    return args

win_CreateFileW = ctypes.WINFUNCTYPE(
        ctypes.wintypes.HANDLE, # return value
        ctypes.wintypes.LPCWSTR, # file_name
        ctypes.wintypes.DWORD, # desired_access
        ctypes.wintypes.DWORD, # share_mode
        ctypes.wintypes.LPVOID, # security_attributes
        ctypes.wintypes.DWORD, # creation_disposition
        ctypes.wintypes.DWORD, # flags_and_attributes
        ctypes.wintypes.HANDLE, # template_file
    )(("CreateFileW", ctypes.windll.kernel32))
win_CreateFileW.errcheck = _error_check_handle

win_ReadDirectoryChangesW = ctypes.WINFUNCTYPE(
        ctypes.wintypes.BOOL, # return value
        ctypes.wintypes.HANDLE, # directory
        ctypes.wintypes.LPVOID, # buffer
        ctypes.wintypes.DWORD, # buffer_length
        ctypes.wintypes.BOOL, # watch_subtree
        ctypes.wintypes.DWORD, # notify_filter
        ctypes.POINTER(ctypes.wintypes.DWORD), # bytes_returned
        ctypes.wintypes.LPVOID, # overlapped
        ctypes.wintypes.LPVOID, # completion_routine
    )(("ReadDirectoryChangesW", ctypes.windll.kernel32))

win_OpenThread = ctypes.WINFUNCTYPE(
        ctypes.wintypes.HANDLE, # return value
        ctypes.wintypes.DWORD, # desired_access
        ctypes.wintypes.BOOL, # inherit_handle
        ctypes.wintypes.DWORD, # thread_id
    )(("OpenThread", ctypes.windll.kernel32))
# Note that OpenThread returns NULL on error, not INVALID_HANDLE_VALUE.
win_OpenThread.errcheck = _error_check_bool

win_CloseHandle = ctypes.WINFUNCTYPE(
        ctypes.wintypes.BOOL, # return value
        ctypes.wintypes.HANDLE, # handle
    )(("CloseHandle", ctypes.windll.kernel32))
win_CloseHandle.errcheck = _error_check_bool

win_CancelSynchronousIo = ctypes.WINFUNCTYPE(
        ctypes.wintypes.BOOL, # return value
        ctypes.wintypes.HANDLE, # thread
    )(("CancelSynchronousIo", ctypes.windll.kernel32))
win_CancelSynchronousIo.errcheck = _error_check_bool

##############################################################################

_monitored_files = {}
_monitored_directories = {}

class _MonitoredFileInfo(object):
    def __init__(self, mod_time, file_change_callback):
        self.mod_time = mod_time
        self.file_change_callback = file_change_callback

def add_file(file_path, mod_time, file_change_callback):
    """Call the callback when the contents of the file at the given absolute
    file path change or the file is deleted.  You can call this function on
    a file that does not exist, and the callback will be called when the file
    is closed for writing after it is created.

    mod_time is the file's current modification time.
    """
    file_path = file_path.replace("\\", "/")
    assert(os.path.isabs(file_path))

    _monitored_files[file_path] = _MonitoredFileInfo(
        mod_time, file_change_callback)
    _ensure_directory_is_monitored(_containing_directory_that_exists(file_path))

def remove_file(file_path):
    """Remove a previous file being monitored for modifications, deletion,
    or creation.
    """
    file_path = file_path.replace("\\", "/")
    del _monitored_files[file_path]

def _containing_directory_that_exists(file_path):
    """Return the path to the directory holding the given file/directory.
    The returned path exists on disk.  For example, if file_path is
    "/one/two/three.txt" and "/one" exists but "/one/two" does not, the return
    value is "/one".
    """
    directory_path = os.path.dirname(file_path)
    while not os.path.isdir(directory_path):
        parent_directory_path = os.path.dirname(directory_path)
        assert(parent_directory_path != directory_path)
        directory_path = parent_directory_path

    return directory_path

def _ensure_directory_is_monitored(directory_path):
    # If we're already monitoring a parent of this directory then there is
    # nothing to do.  If not, see if we're already monitoring a subdirectory of
    # the new directory we want to monitor.
    monitored_child_directories = []
    for monitored_directory_path in _monitored_directories.keys():
        if directory_path.startswith(monitored_directory_path):
            return

        if monitored_directory_path.startswith(directory_path):
            monitored_child_directories.append(monitored_directory_path)

    _monitored_directories[directory_path] = _MonitoredDirectory(
        directory_path)

    # Stop monitoring any child directories before we start monitoring the
    # parent directory.
    for monitored_child_directory in monitored_child_directories:
        _monitored_directories[monitored_child_directory].stop_monitoring()
        del _monitored_directories[monitored_child_directory]

class _MonitoredDirectory(object):
    def __init__(self, directory_path):
        self.directory_path = directory_path

        # Start our monitor thread and wait for it to start running.
        # Unfortunately, the monitor thread can only tell us when it is about
        # to start monitoring the directory, not when it has started to monitor
        # it, so it may not be completely ready yet.
        self.condition = threading.Condition()
        self.condition.acquire()
        self.thread = threading.Thread(target=self._thread_start)
        self.thread.daemon = True
        self.thread.start()
        self.condition.wait()
        self.condition.release()

    def _thread_start(self):
        self.thread_id = threading.current_thread().ident

        # Get a handle for the directory we're monitoring.
        self.directory_handle = win_CreateFileW(
            self.directory_path,
            win_FILE_LIST_DIRECTORY,
            win_FILE_SHARE_READ | win_FILE_SHARE_WRITE | win_FILE_SHARE_DELETE,
            None, # security_attributes
            win_OPEN_EXISTING,
            win_FILE_FLAG_BACKUP_SEMANTICS,
            None # template_file
            )

        # Notify the constructor's thread that we've started.  Note that we
        # can't notify them after we've called ReadDirectoryChangesW because
        # we're using ReadDirectoryChangesW synchronously.
        self.condition.acquire()
        self.condition.notify()
        self.condition.release()

        # Note that the maximum buffer size on Windows is 64KB when monitoring
        # a directory over the network.
        buffer_size = 65536

        # We want to know when a file or directory inside the monitored
        # directory is renamed, created, deleted, or modified.  We're using
        # asynchronous notifications, so the return value should be non-zero if
        # the operation was successfully queued.
        changed_files_buffer = ctypes.create_string_buffer(buffer_size)
        bytes_returned = ctypes.wintypes.DWORD()
        while True:
            result = win_ReadDirectoryChangesW(
                self.directory_handle,
                changed_files_buffer,
                buffer_size,
                True, # watch_subtree
                win_FILE_NOTIFY_CHANGE_FILE_NAME |
                    win_FILE_NOTIFY_CHANGE_DIR_NAME |
                    win_FILE_NOTIFY_CHANGE_LAST_WRITE,
                ctypes.byref(bytes_returned),
                None, # overlapped pointer
                None, # completion routine
                )

            # ReadDirectoryChangesW can exit with an error if the main thread
            # terminated our IO operation, indicating that we should shut down.
            if (result == 0 and
                    ctypes.GetLastError() == win_ERROR_OPERATION_ABORTED):
                break

            assert(result != 0)
            _process_changed_files(changed_files_buffer, self.directory_path)

        win_CloseHandle(self.directory_handle)
        self.directory_handle = None

    def stop_monitoring(self):
        # Cancel the call to ReadDirectoryChangesW from the thread, prompting
        # the thread to shut down.
        thread_handle = win_OpenThread(
            win_THREAD_TERMINATE,
            False, # inherit_handle
            self.thread_id)

        # TODO: If we don't wait, we'll get an "Element not found" (1168)
        #       error when trying to cancel synchronous IO.  Why?
        time.sleep(0.5)

        win_CancelSynchronousIo(thread_handle)
        win_CloseHandle(thread_handle)

        # Since we're using daemon threads, we need to wait for the monitor
        # thread to finish to ensure that it does finish before the process
        # exits.
        self.thread.join()

def _process_changed_files(changed_files_buffer, directory_path):
    for file_path, action in _extract_changed_files(
            changed_files_buffer, directory_path):
        if file_path not in _monitored_files:
            continue

        file_info = _monitored_files[file_path]
        new_mod_time, is_dir = _get_mod_time_and_is_dir(file_path)

        # We want to know when directories are created or deleted, but we
        # don't care when their contents change.
        if is_dir and action == win_FILE_ACTION_MODIFIED:
            continue

        # Note that the file may have been created and removed but we're
        # only being notified of the creation and will be told of the
        # removal next time.  So, check that the file actually exists
        # before calling the callback.  Also, check that the new
        # modification time is newer than the previous one if the file
        # already existed.
        old_mod_time = file_info.mod_time
        if ((new_mod_time is None and old_mod_time is not None)
                or new_mod_time > old_mod_time):
            file_info.mod_time = new_mod_time
            file_info.file_change_callback(file_path)

def _extract_changed_files(buffer, directory_path):
    """Extract the information out of a FILE_NOTIFY_INFORMATION structure."""
    changed_files = []

    offset = 0
    while True:
        bytes_to_skip, action, file_name_length = struct.unpack(
            "iii", buffer[offset:offset+12])
        file_name = buffer[
            offset + 12:offset + 12 + file_name_length].decode("utf_16")
        changed_files.append(
            (directory_path + "/" + file_name.replace("\\", "/"), action))

        if bytes_to_skip == 0:
            break
        offset += bytes_to_skip

    return changed_files

def _get_mod_time_and_is_dir(file_path):
    try:
        stat_value = os.stat(file_path)
    except:
        return None, False

    return stat_value[stat.ST_MTIME], stat.S_ISDIR(stat_value[stat.ST_MODE])

def start():
    # Make sure we cleanly shut down file monitoring when we exit the process.
    atexit.register(_stop_monitoring_all_directories)

def _stop_monitoring_all_directories():
    for directory_path in _monitored_directories.keys():
        _stop_monitoring_directory(directory_path)

def _stop_monitoring_directory(directory_path):
    _monitored_directories[directory_path].stop_monitoring()
    del _monitored_directories[directory_path]

